#!/usr/bin/env ruby

require 'fileutils'
require 'io/console'
require 'json'
require 'net/http'
require 'net/https'
require 'open3'
require 'optparse'
require 'rex/socket'
require 'rex/text'
require 'securerandom'
require 'uri'
require 'yaml'
require 'pg'


include Rex::Text::Color

msfbase = __FILE__
while File.symlink?(msfbase)
  msfbase = File.expand_path(File.readlink(msfbase), File.dirname(msfbase))
end

$:.unshift(File.expand_path(File.join(File.dirname(msfbase), 'lib')))
$:.unshift(ENV['MSF_LOCAL_LIB']) if ENV['MSF_LOCAL_LIB']

require 'msfdb_helpers/pg_ctlcluster'
require 'msfdb_helpers/pg_ctl'
require 'msfdb_helpers/standalone'

require 'msfenv'

@script_name = File.basename(__FILE__)
@framework = File.expand_path(File.dirname(__FILE__))

@localconf = Msf::Config.config_directory
@db = "#{@localconf}/db"
@db_conf = "#{@localconf}/database.yml"
@pg_cluster_conf_root = "#{@localconf}/.local/etc/postgresql"
@db_driver = nil

@ws_tag = 'msf-ws'
@ws_conf = File.join(@framework, "#{@ws_tag}.ru")
@ws_ssl_key_default = "#{@localconf}/#{@ws_tag}-key.pem"
@ws_ssl_cert_default = "#{@localconf}/#{@ws_tag}-cert.pem"
@ws_log = "#{@localconf}/logs/#{@ws_tag}.log"
@ws_pid = "#{@localconf}/#{@ws_tag}.pid"

@current_user = ENV['LOGNAME'] || ENV['USERNAME'] || ENV['USER']
@msf_ws_user = (@current_user || "msfadmin").to_s.strip
@ws_generated_ssl = false
@ws_api_token = nil

@components = %w(database webservice)
@environments = %w(production development)

@options = {
    # When the component value is nil, the user has not yet specified a specific component
    # It will later be defaulted to a more sane value
    component: nil,
    debug: false,
    msf_db_name: 'msf',
    msf_db_user: 'msf',
    msftest_db_name: 'msftest',
    msftest_db_user: 'msftest',
    db_host: '127.0.0.1',
    db_port: 5433,
    db_pool: 200,
    address: 'localhost',
    port: 5443,
    daemon: true,
    ssl: true,
    ssl_cert: @ws_ssl_cert_default,
    ssl_key: @ws_ssl_key_default,
    ssl_disable_verify: true,
    ws_env: ENV['RACK_ENV'] || 'production',
    retry_max: 10,
    retry_delay: 5.0,
    ws_user: nil,
    add_data_service: false,
    data_service_name: nil,
    use_defaults: false,
    delete_existing_data: true
}

def supports_color?
  return true if Rex::Compat.is_windows
  term = Rex::Compat.getenv('TERM')
  term and term.match(/(?:vt10[03]|xterm(?:-color)?|linux|screen|rxvt)/i) != nil
end

class String
  def bold
    substitute_colors("%bld#{self}%clr")
  end

  def underline
    substitute_colors("%und#{self}%clr")
  end

  def red
    substitute_colors("%red#{self}%clr")
  end

  def green
    substitute_colors("%grn#{self}%clr")
  end

  def blue
    substitute_colors("%blu#{self}%clr")
  end

  def cyan
    substitute_colors("%cya#{self}%clr")
  end

end

def pw_gen
  SecureRandom.base64(32)
end

def tail(file)
  begin
    File.readlines(file).last.to_s.strip
  rescue
    nil
  end
end

def status_db
  update_db_port

  case @db_driver.status
  when DatabaseStatus::RUNNING
    puts "Database started"
  when DatabaseStatus::INACTIVE
    puts "Database found, but is not running"
  when DatabaseStatus::NEEDS_INIT
    puts "Database found, but needs initialized"
  when DatabaseStatus::NOT_FOUND
    puts "No database found"
  end
end

def start_db
  case @db_driver.status
  when DatabaseStatus::NOT_FOUND
    print_error 'No database found.'
    return
  when DatabaseStatus::NEEDS_INIT
    print_error 'Has the database been initialized with "msfdb init" or "msfdb init --component database"?'
    return
  end

  update_db_port
  db_started = @db_driver.start

  if !db_started
    last_log = tail("#{@db}/log")
    puts last_log
    if last_log =~ /not compatible/
      puts 'Please attempt to upgrade the database manually using pg_upgrade.'
    end
    print_error 'Your database may be corrupt. Try reinitializing.'
  end
end

def stop_db
  update_db_port
  @db_driver.stop
end

def restart_db
  @db_driver.restart
end

def init_db
  case @db_driver.status
  when DatabaseStatus::RUNNING
    puts 'Existing database running'
    return
  when DatabaseStatus::INACTIVE
    puts 'Existing database found, attempting to start it'
    @db_driver.start
    return
  end

  if @db_driver.exists? && !@options[:delete_existing_data]
    if !load_db_config
      puts 'Failed to load existing database config. Please reinit and overwrite the file.'
      return
    end
  end

  # Generate new database passwords if not already assigned
  @msf_pass ||= pw_gen
  @msftest_pass ||= pw_gen

  @db_driver.init(@msf_pass, @msftest_pass)
  write_db_config

  puts 'Creating initial database schema'
  Dir.chdir(@framework) do
    @db_driver.run_cmd('bundle exec rake db:migrate')
  end
  puts 'Database initialization successful'.green.bold.to_s
end

def load_db_config
  if File.file?(@db_conf)
    config = YAML.load(File.read(@db_conf))

    production = config['production']
    if production.nil?
      puts "No production section found in database config #{@db_conf}."
      return false
    end

    test = config['test']
    if test.nil?
      puts "No test section found in database config #{@db_conf}."
      return false
    end

    # get values for development and production
    @options[:msf_db_name] = production['database']
    @options[:msf_db_user] = production['username']
    @msf_pass = production['password']
    @options[:db_port] = production['port']
    @options[:db_pool] = production['pool']

    # get values for test
    @options[:msftest_db_name] = test['database']
    @options[:msftest_db_user] = test['username']
    @msftest_pass = test['password']
    return true
  end

  return false
end

def write_db_config
  # Write a default database config file
  Dir.mkdir(@localconf) unless File.directory?(@localconf)
  File.open(@db_conf, 'w') do |f|
    f.puts <<~EOF
      development: &pgsql
        adapter: postgresql
        database: #{@options[:msf_db_name]}
        username: #{@options[:msf_db_user]}
        password: #{@msf_pass}
        host: #{@options[:db_host]}
        port: #{@options[:db_port]}
        pool: #{@options[:db_pool]}

      production: &production
        <<: *pgsql

      test:
        <<: *pgsql
        database: #{@options[:msftest_db_name]}
        username: #{@options[:msftest_db_user]}
        password: #{@msftest_pass}
    EOF
  end

  File.chmod(0640, @db_conf)
end

def update_db_port
  if File.file?(@db_conf)
    config = begin
      YAML.load_file(@db_conf, aliases: true) || {}
    rescue ArgumentError
      YAML.load_file(@db_conf) || {}
    end
    if config["production"] && config["production"]["port"]
      port = config["production"]["port"]
      if port != @options[:db_port]
        puts "Using database port #{port} found in #{@db_conf}"
        @options[:db_port] = port
      end
    end
  end
end

def ask_yn(question, default: nil)
  loop do
    print "#{'[?]'.blue.bold} #{question} [#{default}]: "
    input = STDIN.gets.strip
    input = input.empty? ? default : input
    case input
    when /^[Yy]/
      return true
    when /^[Nn]/
      return false
    else
      puts 'Please answer yes or no.'
    end
  end
end

def ask_value(question, default)
  return default if @options[:use_defaults]

  print "#{'[?]'.blue.bold} #{question} [#{default}]: "
  input = STDIN.gets.strip
  if input.nil? || input.empty?
    return default
  else
    return input
  end
end

def ask_password(question)
  print "#{'[?]'.blue.bold} #{question}: "
  input = STDIN.noecho(&:gets).chomp
  print "\n"
  if input.nil? || input.empty?
    return pw_gen
  else
    return input
  end
end

def print_error(error)
  puts "#{'[!]'.red.bold} #{error}"
end

def delete_db
  stop_web_service
  @db_driver.delete
end

def reinit_db
  delete_db
  init_db
end

def print_webservice_removal_prompt
  $stderr.puts "#{'[WARNING]'.red} The remote web service is being removed. Does this impact you? React here: https://github.com/rapid7/metasploit-framework/issues/18439"
end

class WebServicePIDStatus
  RUNNING = 0
  INACTIVE = 1
  NO_PID_FILE = 2
end

class DatabaseStatus
  RUNNING = 0
  INACTIVE = 1
  NOT_FOUND = 2
  NEEDS_INIT = 3
end

def web_service_pid
  File.file?(@ws_pid) ? tail(@ws_pid) : nil
end

def web_service_pid_status
  if File.file?(@ws_pid)
    ws_pid = tail(@ws_pid)
    if ws_pid.nil? || !process_active?(ws_pid.to_i)
      WebServicePIDStatus::INACTIVE
    else
      WebServicePIDStatus::RUNNING
    end
  else
    WebServicePIDStatus::NO_PID_FILE
  end
end

def status_web_service
  ws_pid = web_service_pid
  status = web_service_pid_status
  if status == WebServicePIDStatus::RUNNING
    puts "MSF web service is running as PID #{ws_pid}"
  elsif status == WebServicePIDStatus::INACTIVE
    puts "MSF web service is not running: PID file found at #{@ws_pid}, but no active process running as PID #{ws_pid}"
  elsif status == WebServicePIDStatus::NO_PID_FILE
    puts "MSF web service is not running: no PID file found at #{@ws_pid}"
  end
end

def init_web_service
  if web_service_pid_status == WebServicePIDStatus::RUNNING
    puts "MSF web service is already running as PID #{web_service_pid}"
    return false
  end

  unless @options[:use_defaults]
    if @options[:ws_user].nil?
      @msf_ws_user = ask_value('Initial MSF web service account username?', @msf_ws_user)
    else
      @msf_ws_user = @options[:ws_user]
    end
  end

  if @options[:use_defaults]
    @msf_ws_pass = pw_gen
  elsif @options[:ws_pass].nil?
    @msf_ws_pass = ask_password('Initial MSF web service account password? (Leave blank for random password)')
  else
    @msf_ws_pass = @options[:ws_pass]
  end

  if should_generate_web_service_ssl && @options[:delete_existing_data]
    generate_web_service_ssl(key: @options[:ssl_key], cert: @options[:ssl_cert])
  end

  if start_web_service(expect_auth: false)
    if add_web_service_workspace && add_web_service_user
      output_web_service_information
    else
      puts 'Failed to complete MSF web service configuration, please reinitialize.'
      stop_web_service
    end
  end
end

def start_web_service_daemon(expect_auth:)
  if @db_driver.run_cmd("#{thin_cmd} start") == 0
    # wait until web service is online
    retry_count = 0
    response_data = web_service_online_check(expect_auth: expect_auth)
    is_online = response_data[:state] != :offline
    while !is_online && retry_count < @options[:retry_max]
      retry_count += 1
      if @options[:debug]
        puts "MSF web service doesn't appear to be online. Sleeping #{@options[:retry_delay]}s until check #{retry_count}/#{@options[:retry_max]}"
      end
      sleep(@options[:retry_delay])
      response_data = web_service_online_check(expect_auth: expect_auth)
      is_online = response_data[:state] != :offline
    end

    if response_data[:state] == :online
      puts "#{'success'.green.bold}"
      puts 'MSF web service started and online'
      return true
    elsif response_data[:state] == :error
      puts "#{'failed'.red.bold}"
      print_error 'MSF web service failed and returned the following message:'
      puts "#{response_data[:message].nil? || response_data[:message].empty? ? "No message returned." : response_data[:message]}"
    elsif response_data[:state] == :offline
      puts "#{'failed'.red.bold}"
      print_error 'A connection with the web service was refused.'
    end

    puts "Please see #{@ws_log} for additional webservice details."
    return false
  else
    puts "#{'failed'.red.bold}"
    puts 'Failed to start MSF web service'
    return false
  end
end

def start_web_service(expect_auth: true)
  unless File.file?(@ws_conf)
    puts "No MSF web service configuration found at #{@ws_conf}, not starting"
    return false
  end

  # check if MSF web service is already started
  ws_pid = web_service_pid
  status = web_service_pid_status
  if status == WebServicePIDStatus::RUNNING
    puts "MSF web service is already running as PID #{ws_pid}"
    return false
  elsif status == WebServicePIDStatus::INACTIVE
    puts "MSF web service PID file found, but no active process running as PID #{ws_pid}"
    puts "Deleting MSF web service PID file #{@ws_pid}"
    File.delete(@ws_pid)
  end

  print 'Attempting to start MSF web service...'

  unless File.file?(@options[:ssl_key])
    puts "#{'failed'.red.bold}"
    print_error "The SSL Key needed for the webservice to connect to the database could not be found at #{@options[:ssl_key]}."
    print_error 'Has the webservice been initialized with "msfdb init"  or "msfdb init --component webservice"?'
    return false
  end

  if @options[:daemon]
    start_web_service_daemon(expect_auth: expect_auth)
  else
    puts thin_cmd
    system "#{thin_cmd} start"
  end
end

def stop_web_service
  ws_pid = web_service_pid
  status = web_service_pid_status
  if status == WebServicePIDStatus::RUNNING
    puts "Stopping MSF web service PID #{ws_pid}"
    @db_driver.run_cmd("#{thin_cmd} stop")
  else
    puts 'MSF web service is no longer running'
    if status == WebServicePIDStatus::INACTIVE
      puts "Deleting MSF web service PID file #{@ws_pid}"
      File.delete(@ws_pid)
    end
  end
end

def restart_web_service
  stop_web_service
  start_web_service
end

def delete_web_service
  stop_web_service

  File.delete(@ws_pid) if web_service_pid_status == WebServicePIDStatus::INACTIVE
  if @options[:delete_existing_data]
    File.delete(@options[:ssl_key]) if File.file?(@options[:ssl_key])
    File.delete(@options[:ssl_cert]) if File.file?(@options[:ssl_cert])
  end
end

def reinit_web_service
  delete_web_service
  init_web_service
end

def generate_web_service_ssl(key:, cert:)
  @ws_generated_ssl = true
  if (File.file?(key) || File.file?(cert)) && !@options[:delete_existing_data]
    return
  end

  puts 'Generating SSL key and certificate for MSF web service'
  @ssl_key, @ssl_cert, @ssl_extra_chain_cert = Rex::Socket::Ssl.ssl_generate_certificate

  # write PEM format key and certificate
  mode = 'wb'
  mode_int = 0600
  File.open(key, mode) { |f| f.write(@ssl_key.to_pem) }
  File.chmod(mode_int, key)

  File.open(cert, mode) { |f| f.write(@ssl_cert.to_pem) }
  File.chmod(mode_int, cert)
end

def web_service_online_check(expect_auth:)
  msf_version_uri = get_web_service_uri(path: '/api/v1/msf/version')
  response_data = http_request(uri: msf_version_uri, method: :get,
                          skip_verify: skip_ssl_verify?, cert: get_ssl_cert)

  if !response_data[:exception].nil? && response_data[:exception].is_a?(Errno::ECONNREFUSED)
    response_data[:state] = :offline
  elsif !response_data[:exception].nil? && response_data[:exception].is_a?(OpenSSL::OpenSSLError)
    response_data[:state] = :error
    response_data[:message] = 'Detected an SSL issue. Please set the same options used to initialize the web service or reinitialize.'
  elsif !response_data[:response].nil? && response_data[:response].dig(:error, :code) == 401
    if expect_auth
      response_data[:state] = :online
    else
      response_data[:state] = :error
      response_data[:message] = 'MSF web service expects authentication. If you wish to reinitialize the web service account you will need to reinitialize the database.'
    end
  elsif !response_data[:response].nil? && !response_data[:response].dig(:data, :metasploit_version).nil?
    response_data[:state] = :online
  else
    response_data[:state] = :error
  end

  puts "web_service_online: expect_auth=#{expect_auth}, response_msg=#{response_data}" if @options[:debug]
  response_data
end

def add_web_service_workspace(name: 'default')
  # Send request to create new workspace
  workspace_data = { name: name }
  workspaces_uri = get_web_service_uri(path: '/api/v1/workspaces')
  response_data = http_request(uri: workspaces_uri, data: workspace_data, method: :post,
                               skip_verify: skip_ssl_verify?, cert: get_ssl_cert)
  response = response_data[:response]
  puts "add_web_service_workspace: add workspace response=#{response}" if @options[:debug]
  if response.nil? || response.dig(:data, :name) != name
    print_error "Error creating MSF web service workspace '#{name}'"
    return false
  end
  return true
end

def add_web_service_user
  puts "Creating MSF web service user #{@msf_ws_user}"

  # Generate new web service user password
  cred_data = { username: @msf_ws_user, password: @msf_ws_pass }

  # Send request to create new admin user
  user_data = cred_data.merge({ admin: true })
  user_uri = get_web_service_uri(path: '/api/v1/users')
  response_data = http_request(uri: user_uri, data: user_data, method: :post,
                          skip_verify: skip_ssl_verify?, cert: get_ssl_cert)
  response = response_data[:response]
  puts "add_web_service_user: create user response=#{response}" if @options[:debug]
  if response.nil? || response.dig(:data, :username) != @msf_ws_user
    print_error "Error creating MSF web service user #{@msf_ws_user}"
    return false
  end

  puts "\n#{'    ############################################################'.cyan}"
  print "#{'    ##              '.cyan}"
  print"#{'MSF Web Service Credentials'.cyan.bold.underline}"
  puts"#{'               ##'.cyan}"
  puts "#{'    ##                                                        ##'.cyan}"
  puts "#{'    ##        Please store these credentials securely.        ##'.cyan}"
  puts "#{'    ##    You will need them to connect to the webservice.    ##'.cyan}"
  puts "#{'    ############################################################'.cyan}"

  puts "\n#{'MSF web service username'.cyan.bold}: #{@msf_ws_user}"
  puts "#{'MSF web service password'.cyan.bold}: #{@msf_ws_pass}"

  # Send request to create new API token for the user
  generate_token_uri = get_web_service_uri(path: '/api/v1/auth/generate-token')
  response_data = http_request(uri: generate_token_uri, data: cred_data, method: :post,
                          skip_verify: skip_ssl_verify?, cert: get_ssl_cert)
  response = response_data[:response]
  puts "add_web_service_user: generate token response=#{response}" if @options[:debug]
  if response.nil? || (@ws_api_token = response.dig(:data, :token)).nil?
    print_error "Error creating MSF web service user API token"
    return false
  end
  puts "#{'MSF web service user API token'.cyan.bold}: #{@ws_api_token}"
  return true
end

def output_web_service_information
  puts "\n\n"
  puts 'MSF web service configuration complete'
  if @options[:add_data_service]
    data_service_name = @options[:data_service_name] || "local-#{@options[:ssl] ? 'https' : 'http'}-data-service"
    puts "The web service has been configured as your default data service in msfconsole with the name \"#{data_service_name}\""
  else
    puts "No data service has been configured in msfconsole."
  end
  puts ''
  puts 'If needed, manually reconnect to the data service in msfconsole using the command:'
  puts "#{get_db_connect_command}"
  puts ''
  puts 'The username and password are credentials for the API account:'
  puts "#{get_web_service_uri(path: '/api/v1/auth/account')}"
  puts ''

  if @options[:add_data_service]
    persist_data_service
  end
end

def run_msfconsole_command(cmd)
  # Attempts to run a the metasploit command first with the default env settings, and once again with the path set
  # to the current directory. This ensures that it works in an environment such as bundler
  # @msf_command holds the initial common part of commands (msfconsole -qx) and takes the optional specific commands as arguments (#{cmd})
  msf_command = "msfconsole -qx '#{cmd}'"
  if @db_driver.run_cmd(msf_command) != 0
    # attempt to execute msfconsole in the current working directory
    if @db_driver.run_cmd(msf_command, env: {'PATH' => ".:#{ENV["PATH"]}"}) != 0
      puts 'Failed to run msfconsole'
    end
  end
end

def persist_data_service
  puts 'Persisting http web data service credentials in msfconsole'
  # execute msfconsole commands to add and persist the data service connection
  cmd = "#{get_db_connect_command}; db_save; exit"
  run_msfconsole_command(cmd)
end

def get_db_connect_command
  data_service_name = "local-#{@options[:ssl] ? 'https' : 'http'}-data-service"
  if !@options[:data_service_name].nil?
    data_service_name = @options[:data_service_name]
  end

  # build db_remove and db_connect command based on install options
  connect_cmd = "db_connect"
  connect_cmd << " --name #{data_service_name}"
  connect_cmd << " --token #{@ws_api_token}"
  connect_cmd << " --cert #{@options[:ssl_cert]}" if @options[:ssl]
  connect_cmd << " --skip-verify" if skip_ssl_verify?
  connect_cmd << " #{get_web_service_uri}"
  connect_cmd
end

def get_web_service_uri(path: nil)
  uri_class = @options[:ssl] ? URI::HTTPS : URI::HTTP
  uri_class.build({host: get_web_service_host, port: @options[:port], path: path})
end

def get_web_service_host
  # user specified any address INADDR_ANY (0.0.0.0), return a routable address
  @options[:address] == '0.0.0.0' ? 'localhost' : @options[:address]
end

def skip_ssl_verify?
  @ws_generated_ssl || @options[:ssl_disable_verify]
end

def get_ssl_cert
  @options[:ssl] ? @options[:ssl_cert] : nil
end

# TODO: In the future this can be replaced by Msf::WebServices::HttpDBManagerService
def thin_cmd
  server_opts = "--rackup #{@ws_conf.shellescape} --address #{@options[:address].shellescape} --port #{@options[:port]}"
  ssl_opts = @options[:ssl] ? "--ssl --ssl-key-file #{@options[:ssl_key].shellescape} --ssl-cert-file #{@options[:ssl_cert].shellescape}" : ''
  ssl_opts << ' --ssl-disable-verify' if skip_ssl_verify?
  adapter_opts = "--environment #{@options[:ws_env]}"
  daemon_opts = "--daemonize --log #{@ws_log.shellescape} --pid #{@ws_pid.shellescape} --tag #{@ws_tag}" if @options[:daemon]
  all_opts = [server_opts, ssl_opts, adapter_opts, daemon_opts].reject(&:blank?).join(' ')

  "thin #{all_opts}"
end

def process_active?(pid)
  begin
    Process.kill(0, pid)
    true
  rescue Errno::ESRCH
    false
  end
end

def http_request(uri:, query: nil, data: nil, method: :get, headers: nil, skip_verify: false, cert: nil)
  all_headers = { 'User-Agent': @script_name }
  all_headers.merge!(headers) unless headers.nil?
  query_str = (!query.nil? && !query.empty?) ? URI.encode_www_form(query.compact) : nil
  uri.query = query_str

  http = Net::HTTP.new(uri.host, uri.port)
  if uri.is_a?(URI::HTTPS)
    http.use_ssl = true
    if skip_verify
      http.verify_mode = OpenSSL::SSL::VERIFY_NONE
    else
      # https://stackoverflow.com/questions/22093042/implementing-https-certificate-pubkey-pinning-with-ruby
      http.verify_mode = OpenSSL::SSL::VERIFY_PEER
      user_passed_cert = OpenSSL::X509::Certificate.new(File.read(cert))

      http.verify_callback = lambda do |preverify_ok, cert_store|
        server_cert = cert_store.chain[0]
        return true unless server_cert.to_der == cert_store.current_cert.to_der
        same_public_key?(server_cert, user_passed_cert)
      end
    end
  end

  begin
    response_data = { response: nil }
    case method
      when :get
        request = Net::HTTP::Get.new(uri.request_uri, initheader=all_headers)
      when :post
        request = Net::HTTP::Post.new(uri.request_uri, initheader=all_headers)
      else
        raise Exception, "Request method #{method} is not handled"
    end

    request.content_type = 'application/json'
    unless data.nil?
      json_body = data.to_json
      request.body = json_body
    end

    response = http.request(request)
    unless response.body.nil? || response.body.empty?
      response_data[:response] = JSON.parse(response.body, symbolize_names: true)
    end
  rescue => e
    response_data[:exception] = e
    puts "Problem with HTTP #{method} request #{uri.request_uri}, message: #{e.message}" if @options[:debug]
  end

  response_data
end

# Tells us whether the private keys on the passed certificates match
# and use the same algo
def same_public_key?(ref_cert, actual_cert)
  pkr, pka = ref_cert.public_key, actual_cert.public_key

  # First check if the public keys use the same crypto...
  return false unless pkr.class == pka.class
  # ...and then - that they have the same contents
  return false unless pkr.to_pem == pka.to_pem

  true
end

def parse_args(args)
  subtext = <<~USAGE
    Commands:
      init     initialize the component
      reinit   delete and reinitialize the component
      delete   delete and stop the component
      status   check component status
      start    start the component
      stop     stop the component
      restart  restart the component
  USAGE

  parser = OptionParser.new do |opts|
    opts.banner = "Usage: #{@script_name} [options] <command>"
    opts.separator('Manage a Metasploit Framework database and web service')
    opts.separator('')
    opts.separator('General Options:')
    opts.on('--component COMPONENT', @components + ['all'], 'Component used with provided command (default: database)',
            "  (#{@components.join(', ')})") { |component|
      @options[:component] = component.to_sym
    }

    opts.on('-d', '--debug', 'Enable debug output') { |d| @options[:debug] = d }
    opts.on('-h', '--help', 'Show this help message') {
      puts opts
      exit
    }
    opts.on('--use-defaults', 'Accept all defaults and do not prompt for options during an init') { |d|
      @options[:use_defaults] = d
    }

    opts.separator('')
    opts.separator('Database Options:')
    opts.on('--msf-db-name NAME', "Database name (default: #{@options[:msf_db_name]})") { |n|
      @options[:msf_db_name] = n
    }

    opts.on('--msf-db-user-name USER', "Database username (default: #{@options[:msf_db_user]})") { |u|
      @options[:msf_db_user] = u
    }

    opts.on('--msf-test-db-name NAME', "Test database name (default: #{@options[:msftest_db_name]})") { |n|
      @options[:msftest_db_name] = n
    }

    opts.on('--msf-test-db-user-name USER', "Test database username (default: #{@options[:msftest_db_user]})") { |u|
      @options[:msftest_db_user] = u
    }

    opts.on('--db-port PORT', Integer, "Database port (default: #{@options[:db_port]})") { |p|
      @options[:db_port] = p
    }

    opts.on('--db-pool MAX', Integer, "Database connection pool size (default: #{@options[:db_pool]})") { |m|
      @options[:db_pool] = m
    }

    opts.on('--connection-string URI', 'Use a pre-existing database cluster for initialization',
            'Example: --connection-string=postgresql://postgres:mysecretpassword@localhost:5432/postgres') { |c|
      @connection_string = c
    }

    opts.separator('')
    opts.separator('Web Service Options:')
    opts.on('-a', '--address ADDRESS',
            "Bind to host address (default: #{@options[:address]})") { |a|
      @options[:address] = a
    }

    opts.on('-p', '--port PORT', Integer,
            "Web service port (default: #{@options[:port]})") { |p|
      @options[:port] = p
    }

    opts.on('--[no-]daemon', 'Enable daemon') { |d|
      @options[:daemon] = d
    }

    opts.on('--[no-]ssl', "Enable SSL (default: #{@options[:ssl]})") { |s| @options[:ssl] = s }

    opts.on('--ssl-key-file PATH', "Path to private key (default: #{@options[:ssl_key]})") { |p|
      @options[:ssl_key] = p
    }

    opts.on('--ssl-cert-file PATH', "Path to certificate (default: #{@options[:ssl_cert]})") { |p|
      @options[:ssl_cert] = p
    }

    opts.on('--[no-]ssl-disable-verify',
            "Disables (optional) client cert requests (default: #{@options[:ssl_disable_verify]})") { |v|
      @options[:ssl_disable_verify] = v
    }

    opts.on('--environment ENV', @environments,
            "Web service framework environment (default: #{@options[:ws_env]})",
            "  (#{@environments.join(', ')})") { |e|
      @options[:ws_env] = e
    }

    opts.on('--retry-max MAX', Integer,
            "Maximum number of web service connect attempts (default: #{@options[:retry_max]})") { |m|
      @options[:retry_max] = m
    }

    opts.on('--retry-delay DELAY', Float,
            "Delay in seconds between web service connect attempts (default: #{@options[:retry_delay]})") { |d|
      @options[:retry_delay] = d
    }

    opts.on('--user USER', 'Initial web service admin username') { |u|
      @options[:ws_user] = u
    }

    opts.on('--pass PASS', 'Initial web service admin password') { |p|
      @options[:ws_pass] = p
    }

    opts.on('--[no-]msf-data-service NAME', 'Local msfconsole data service connection name') { |n|
      if !n
        @options[:add_data_service] = false
      else
        @options[:add_data_service] = true
        @options[:data_service_name] = n
      end
    }

    opts.separator('')
    opts.separator(subtext)
  end

  parser.parse!(args)

  if args.length != 1
    puts parser
    abort
  end

  @options
end

def invoke_command(commands, component, command)
  method = commands[component][command]
  if !method.nil?
    send(method)
  else
    print_error "Error: unrecognized command '#{command}' for #{component}"
  end
end

def installed?(cmd)
  !Msf::Util::Helper.which(cmd).nil?
end

def has_requirements(postgresql_cmds)
  ret_val = true
  other_cmds = %w(bundle thin)
  missing_msg = "Missing requirement: %<name>s does not appear to be installed or '%<prog>s' is not in the environment path"

  postgresql_cmds.each do |cmd|
    next unless Msf::Util::Helper.which(cmd).nil?
    puts missing_msg % { name: 'PostgreSQL', prog: cmd }
    ret_val = false
  end

  other_cmds.each do |cmd|
    if Msf::Util::Helper.which(cmd).nil?
      puts missing_msg % { name: "'#{cmd}'", prog: cmd }
      ret_val = false
    end
  end

  ret_val
end

def should_generate_web_service_ssl
  @options[:ssl] && ((!File.file?(@options[:ssl_key]) || !File.file?(@options[:ssl_cert])) ||
      (@options[:ssl_key] == @ws_ssl_key_default && @options[:ssl_cert] == @ws_ssl_cert_default))
end

def prompt_for_component(command)
  if command == :status || command == :delete
    return :all
  end

  if command == :stop && web_service_pid_status != WebServicePIDStatus::RUNNING
    return :database
  end

  if @options[:add_data_service] == true
    :all
  else
    :database
  end
end

def prompt_for_deletion(command)
  destructive_operations = [:reinit, :delete]

  if destructive_operations.include? command
    @options[:delete_existing_data] = should_delete
  end
end

def should_delete
  return true if @options[:use_defaults]
  ask_yn("Would you like to delete your existing data and configurations?")
end

if $PROGRAM_NAME == __FILE__
  # Bomb out if we're root
  if !Gem.win_platform? && Process.uid.zero?
    puts "Please run #{@script_name} as a non-root user"
    abort
  end

  # map component commands to methods
  commands = {
      database: {
          init: :init_db,
          reinit: :reinit_db,
          delete: :delete_db,
          status: :status_db,
          start: :start_db,
          stop: :stop_db,
          restart: :restart_db
      },
      webservice: {
          init: :init_web_service,
          reinit: :reinit_web_service,
          delete: :delete_web_service,
          status: :status_web_service,
          start: :start_web_service,
          stop: :stop_web_service,
          restart: :restart_web_service
      }
  }

  parse_args(ARGV)
  update_db_port

  if @connection_string
    @db_driver = MsfdbHelpers::Standalone.new(options: @options, db_conf: @db_conf, connection_string: @connection_string)
  elsif installed?('pg_ctl') && has_requirements(MsfdbHelpers::PgCtl.requirements)
    @db_driver = MsfdbHelpers::PgCtl.new(db_path: @db, options: @options, localconf: @localconf, db_conf: @db_conf)
  elsif installed?('pg_ctlcluster') && has_requirements(MsfdbHelpers::PgCtlcluster.requirements)
    @db_driver = MsfdbHelpers::PgCtlcluster.new(db_path: @db, options: @options, localconf: @localconf, db_conf: @db_conf)
  else
    print_error('You need to have postgres installed or specify a database with --connection-string')
    abort
  end

  command = ARGV[0].to_sym
  if @options[:component].nil?
    @options[:component] = prompt_for_component(command)
  end
  prompt_for_deletion(command)
  if @options[:component] == :all
    @components.each { |component|
      if component == :webservice
        3.times { print_webservice_removal_prompt }
      end
      puts '===================================================================='
      puts "Running the '#{command}' command for the #{component}:"
      invoke_command(commands, component.to_sym, command)
      puts '===================================================================='
      puts
    }
  else
    puts "Running the '#{command}' command for the #{@options[:component]}:"
    if @options[:component] == :webservice
      3.times { print_webservice_removal_prompt }
    end
    invoke_command(commands, @options[:component], command)
  end
end
